'use strict';

const _ = require('lodash');
const mongoose = require('mongoose');

const isMongoId = value => {
  if (value instanceof mongoose.Types.ObjectId) {
    return true;
  }

  if (!_.isString(value)) {
    return false;
  }

  const hexadecimal = /^[0-9A-F]+$/i;
  return hexadecimal.test(value) && value.length === 24;
};

const valueToId = value => {
  if (isMongoId(value)) {
    return mongoose.Types.ObjectId(value);
  }

  return value;
};

const castValueToType = ({ type, value }) => {
  switch (type) {
    case 'Boolean': {
      if ([ 'true', 't', '1', 1, true ].includes(value)) {
        return true;
      }

      if ([ 'false', 'f', '0', 0 ].includes(value)) {
        return false;
      }

      return Boolean(value);
    }
    case 'String': {
      return String(value);
    }
    case 'Number': {
      return Number(value);
    }
    case 'Date': {
      return new Date(value);
    }
    default:
      return value;
  }
};

const castValue = ({ type, value, operator }) => {
  if (operator === 'null') return castValueToType({ type: 'Boolean', value });
  return castValueToType({ type, value });
};

const buildQuery = async ({
  models,
  model,
  filters = {},
  projection = {},
  populate = [],
  aggregate = false,
  sqlParams,
} = {}) => {
  filters.where = (filters.where || []).map(item => {
    try {
      item.value = JSON.parse(item.value);
    } catch (ex) {

    } finally {
      return item;
    }
  });
  const whereReducer = where => {
    where = where.reduce((acc, { field, operator, value }) => {
      if (field === '_or') {
        acc.push({ field, operator, value: whereReducer(value) });
        return acc;
      }
      if (!_.isNil(value)) {
        let modelField = null;
        if (field.split('.').length > 1) {
          acc.push({ field, operator, value });
          return acc;
        }
        modelField = model.schema.path(field);

        if (modelField) {
          const { instance: type } = modelField;

          let castedValue = null;
          if (type !== 'Array' && Array.isArray(value)) {
            castedValue = value.map(value => castValue({ type, operator, value }));
          } else {
            castedValue = castValue({ type, operator, value });
          }

          acc.push({ field, operator, value: castedValue });
        }
      }
      return acc;
    }, []);
    return where;
  };
  filters.where = whereReducer(filters.where);

  return buildSimpleQuery({ model, filters, projection, populate, sqlParams });

  // return buildDeepQuery({ models, model, filters, populate });
};

const buildDeepQuery = ({ models, model, filters, populate }) => {
  // Build a tree of paths to populate based on the filtering and the populate option
  const { populatePaths, wherePaths } = computePopulatedPaths({
    models,
    model,
    populate,
    where: filters.where,
  });

  // Init the query
  const query = model
    .aggregate(
      buildQueryAggregate(model, {
        paths: _.merge({}, populatePaths, wherePaths),
      })
    )
    .append(buildQueryMatches(model, filters));

  return {
    /**
     * Overrides the promise to rehydrate mongoose docs after the aggregation query
     */
    then(...args) {
      return query
        .append({
          $project: { _id: true },
        })
        .then(results => results.map(el => el._id))
        .then(ids => {
          if (ids.length === 0) return [];

          const query = model
            .find({
              _id: {
                $in: ids,
              },
            })
            .populate(populate);

          return applyQueryParams({ query, filters });
        })
        .then(...args);
    },
    catch(...args) {
      return this.then(r => r)
        .catch(...args);
    },
    /**
     * Maps to query.count
     */
    count() {
      return query
        .count('count')
        .then(results => _.get(results, [ '0', 'count' ], 0));
    },

    /**
     * Maps to query group
     */
    group(...args) {
      return query.group(...args);
    },
    /**
     * Returns an array of plain JS object instead of mongoose documents
     */
    lean() {
      // Returns plain js objects without the transformations we normally do on find
      return this.then(results => {
        return results.map(r => r.toObject({ transform: false }));
      });
    },
  };
};
const buildQueryAggregate = (model, { paths } = {}) => {
  return Object.keys(paths)
    .reduce((acc, key) => {
      return acc.concat(buildLookup({ model, key, paths: paths[key] }));
    }, []);
};

const buildLookup = ({ model, key, paths }) => {
  const assoc = model.associations.find(a => a.alias === key);
  const assocModel = findModelByAssoc({ assoc });

  if (!assocModel) return [];

  return [
    {
      $lookup: {
        from: assocModel.collectionName,
        as: assoc.alias,
        let: {
          localId: '$_id',
          localAlias: `$${assoc.alias}`,
        },
        pipeline: []
          .concat(buildLookupMatch({ assoc }))
          .concat(buildQueryAggregate(assocModel, { paths })),
      },
    },
  ];
};

const computePopulatedPaths = ({ models, model, populate = [], where = [] }) => {
  const castedPopulatePaths = populate
    .map(el => (Array.isArray(el) ? el.join('.') : el))
    .map(path => findModelPath({ models, rootModel: model, path }))
    .map(path => {
      const assocModel = findModelByPath({ models, rootModel: model, path });

      // autoload morph relations
      const extraPaths = [];
      if (assocModel) {
        Object.keys(assocModel.schema.paths)
          .forEach(key => {
            if (!key.startsWith('_') && Object.keys(models)
              .includes(_.upperFirst(key))) {
              extraPaths.push(`${path}.${key}`);
            }
          });
      }

      return [ path, ...extraPaths ];
    })
    .reduce((acc, paths) => acc.concat(paths), []);

  const castedWherePaths = where
    .map(({ field }) => findModelPath({ models, rootModel: model, path: field }))
    .filter(path => !!path);

  return {
    populatePaths: pathsToTree(castedPopulatePaths),
    wherePaths: pathsToTree(castedWherePaths),
  };
};

const pathsToTree = paths =>
  paths.reduce((acc, path) => _.merge(acc, _.set({}, path, {})), {});

const findModelPath = ({ models, rootModel, path }) => {
  const modelsKeys = Object.keys(models);

  let parts = [];
  if (typeof path === 'object' && path !== null) {
    while (true) {
      parts.push(path.path);
      if (path.populate) {
        path = path.populate;
      } else {
        break;
      }
    }
  } else {
    parts = path.split('.');
  }

  let tmpModel = rootModel;
  const tmpPath = [];
  for (let part of parts) {
    part = _.upperFirst(part);
    const assoc = modelsKeys.includes(part);
    if (assoc) {
      tmpModel = models[part];
      tmpPath.push(part);
    }
  }

  return tmpPath.length > 0 ? tmpPath.join('.') : null;
};

const findModelByPath = ({ models, rootModel, path }) => {
  const parts = path.split('.');
  const modelsKeys = Object.keys(models);

  let tmpModel = rootModel;
  for (let part of parts) {
    part = _.upperFirst(part);
    const assoc = modelsKeys.includes(part);
    if (assoc) {
      tmpModel = models[part];
    }
  }

  return tmpModel;
};

const buildSimpleQuery = async ({ model, filters, projection, populate, sqlParams = null }) => {
  const { where = [] } = filters;

  const wheres = where.map(buildWhereClause);
  let findCriteria = wheres.length > 0 ? { $and: wheres } : {};

  // 提供扩展功能，可以手动传递 SQL
  if (sqlParams) {
    findCriteria = sqlParams;
  }

  let query = model.find(findCriteria, projection)
    .populate(populate)
    .lean();

  query = applyQueryParams({ query, filters });
  // 控制分页相关
  if (filters.page) {
    const count = await model.countDocuments(findCriteria);
    return {
      list: await query.find(),
      page: {
        totalCount: count,
        totalPage: Math.ceil(count / filters.limit),
        start: filters.start,
        limit: filters.limit,
        currentPage: filters.start === 0 ? 1 : Math.ceil(filters.start / filters.limit) + 1,
      },
    };
  }

  return Object.assign(query, {
    // Override count to use countDocuments on simple find query
    count(...args) {
      return query.countDocuments(...args);
    },
  });


};

const applyQueryParams = ({ query, filters }) => {
  // Apply sort param
  if (_.has(filters, 'sort')) {
    const sortFilter = filters.sort.reduce((acc, sort) => {
      const { field, order } = sort;
      acc[field] = order === 'asc' ? 1 : -1;
      return acc;
    }, {});

    query = query.sort(sortFilter);
  }

  // Apply start param
  if (_.has(filters, 'start')) {
    query = query.skip(filters.start);
  }

  // Apply limit param
  if (_.has(filters, 'limit') && filters.limit >= 0) {
    query = query.limit(filters.limit);
  }

  return query;
};


const buildWhereClause = ({ field, operator, value }) => {

  if (Array.isArray(value) && field === '_or') {
    return {
      $or: value.map(val => buildWhereClause(val)),
    };
  }

  if (Array.isArray(value) && ![ 'in', 'nin', 'near' ].includes(operator)) {
    return {
      $or: value.map(val => buildWhereClause({ field, operator, value: val })),
    };
  }

  const val = valueToId(value);

  switch (operator) {
    case 'eq':
      return {
        [field]: val,
      };
    case 'ne':
      return {
        [field]: { $ne: val },
      };
    case 'lt':
      return {
        [field]: { $lt: val },
      };
    case 'lte':
      return {
        [field]: { $lte: val },
      };
    case 'gt':
      return {
        [field]: { $gt: val },
      };
    case 'gte':
      return {
        [field]: { $gte: val },
      };
    case 'in':
      return {
        [field]: {
          $in: Array.isArray(val) ? val : [ val ],
        },
      };
    case 'nin':
      return {
        [field]: {
          $nin: Array.isArray(val) ? val : [ val ],
        },
      };
    case 'contains': {
      return {
        [field]: {
          $regex: `${val}`,
          $options: 'i',
        },
      };
    }
    case 'ncontains':
      return {
        [field]: {
          $not: new RegExp(val, 'i'),
        },
      };
    case 'containss':
      return {
        [field]: {
          $regex: `${val}`,
        },
      };
    case 'ncontainss':
      return {
        [field]: {
          $not: new RegExp(val),
        },
      };
    case 'near': {
      return {
        [field.split('.')[0]]: {
          $near: {
            $geometry: {
              type: 'Point',
              [field.split('.')[1]]: Array.isArray(value) ? value : value.split(','),
            },
          },
        },
      };
    }
    case 'null': {
      return value ? {
        [field]: { $eq: null },
      } : {
        [field]: { $ne: null },
      };
    }

    default:
      throw new Error(`Unhandled whereClause : ${field} ${operator} ${value}`);
  }
};

module.exports = buildQuery;
